/*
 * Copyright (C) 2007 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.dx.cf.direct;

import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;

import com.android.dx.util.FileUtils;

/**
 * Opens all the class files found in a class path element. Path elements can
 * point to class files, {jar,zip,apk} files, or directories containing class
 * files.
 */
public class ClassPathOpener {

	/** {@code non-null;} pathname to start with */
	private final String pathname;
	/** {@code non-null;} callback interface */
	private final Consumer consumer;
	/**
	 * If true, sort such that classes appear before their inner classes and
	 * "package-info" occurs before all other classes in that package.
	 */
	private final boolean sort;

	/**
	 * Callback interface for {@code ClassOpener}.
	 */
	public interface Consumer {

		/**
		 * Provides the file name and byte array for a class path element.
		 * 
		 * @param name
		 *            {@code non-null;} filename of element. May not be a valid
		 *            filesystem path.
		 * @param lastModified
		 *            milliseconds since 1970-Jan-1 00:00:00 GMT
		 * @param bytes
		 *            {@code non-null;} file data
		 * @return true on success. Result is or'd with all other results from
		 *         {@code processFileBytes} and returned to the caller of
		 *         {@code process()}.
		 */
		boolean processFileBytes(String name, long lastModified, byte[] bytes);

		/**
		 * Informs consumer that an exception occurred while processing this
		 * path element. Processing will continue if possible.
		 * 
		 * @param ex
		 *            {@code non-null;} exception
		 */
		void onException(Exception ex);

		/**
		 * Informs consumer that processing of an archive file has begun.
		 * 
		 * @param file
		 *            {@code non-null;} archive file being processed
		 */
		void onProcessArchiveStart(File file);
	}

	/**
	 * Constructs an instance.
	 * 
	 * @param pathname
	 *            {@code non-null;} path element to process
	 * @param sort
	 *            if true, sort such that classes appear before their inner
	 *            classes and "package-info" occurs before all other classes in
	 *            that package.
	 * @param consumer
	 *            {@code non-null;} callback interface
	 */
	public ClassPathOpener(String pathname, boolean sort, Consumer consumer) {
		this.pathname = pathname;
		this.sort = sort;
		this.consumer = consumer;
	}

	/**
	 * Processes a path element.
	 * 
	 * @return the OR of all return values from
	 *         {@code Consumer.processFileBytes()}.
	 */
	public boolean process() {
		File file = new File(pathname);

		return processOne(file, true);
	}

	/**
	 * Processes one file.
	 * 
	 * @param file
	 *            {@code non-null;} the file to process
	 * @param topLevel
	 *            whether this is a top-level file (that is, specified directly
	 *            on the commandline)
	 * @return whether any processing actually happened
	 */
	private boolean processOne(File file, boolean topLevel) {
		try {
			if (file.isDirectory()) {
				return processDirectory(file, topLevel);
			}

			String path = file.getPath();

			if (path.endsWith(".zip") || path.endsWith(".jar")
					|| path.endsWith(".apk")) {
				return processArchive(file);
			}

			byte[] bytes = FileUtils.readFile(file);
			return consumer.processFileBytes(path, file.lastModified(), bytes);
		} catch (Exception ex) {
			consumer.onException(ex);
			return false;
		}
	}

	/**
	 * Sorts java class names such that outer classes preceed their inner
	 * classes and "package-info" preceeds all other classes in its package.
	 * 
	 * @param a
	 *            {@code non-null;} first class name
	 * @param b
	 *            {@code non-null;} second class name
	 * @return {@code compareTo()}-style result
	 */
	private static int compareClassNames(String a, String b) {
		// Ensure inner classes sort second
		a = a.replace('$', '0');
		b = b.replace('$', '0');

		/*
		 * Assuming "package-info" only occurs at the end, ensures package-info
		 * sorts first.
		 */
		a = a.replace("package-info", "");
		b = b.replace("package-info", "");

		return a.compareTo(b);
	}

	/**
	 * Processes a directory recursively.
	 * 
	 * @param dir
	 *            {@code non-null;} file representing the directory
	 * @param topLevel
	 *            whether this is a top-level directory (that is, specified
	 *            directly on the commandline)
	 * @return whether any processing actually happened
	 */
	private boolean processDirectory(File dir, boolean topLevel) {
		if (topLevel) {
			dir = new File(dir, ".");
		}

		File[] files = dir.listFiles();
		int len = files.length;
		boolean any = false;

		if (sort) {
			Arrays.sort(files, new Comparator<File>() {

				public int compare(File a, File b) {
					return compareClassNames(a.getName(), b.getName());
				}
			});
		}

		for (int i = 0; i < len; i++) {
			any |= processOne(files[i], false);
		}

		return any;
	}

	/**
	 * Processes the contents of an archive ({@code .zip}, {@code .jar}, or
	 * {@code .apk}).
	 * 
	 * @param file
	 *            {@code non-null;} archive file to process
	 * @return whether any processing actually happened
	 * @throws IOException
	 *             on i/o problem
	 */
	private boolean processArchive(File file) throws IOException {
		ZipFile zip = new ZipFile(file);
		ByteArrayOutputStream baos = new ByteArrayOutputStream(40000);
		byte[] buf = new byte[20000];
		boolean any = false;

		ArrayList<? extends java.util.zip.ZipEntry> entriesList = Collections
				.list(zip.entries());

		if (sort) {
			Collections.sort(entriesList, new Comparator<ZipEntry>() {

				public int compare(ZipEntry a, ZipEntry b) {
					return compareClassNames(a.getName(), b.getName());
				}
			});
		}

		consumer.onProcessArchiveStart(file);

		for (ZipEntry one : entriesList) {
			if (one.isDirectory()) {
				continue;
			}

			String path = one.getName();
			InputStream in = zip.getInputStream(one);

			baos.reset();
			for (;;) {
				int amt = in.read(buf);
				if (amt < 0) {
					break;
				}

				baos.write(buf, 0, amt);
			}

			in.close();

			byte[] bytes = baos.toByteArray();
			any |= consumer.processFileBytes(path, one.getTime(), bytes);
		}

		zip.close();
		return any;
	}
}
